基于 cmdr v1.0.3

从 golang flag 迁移到 cmdr

采用一个新的命令行解释器框架,最痛苦地莫过于编写数据结构或者流式定义了。我们首先回顾一下 cmdr 和其它大多数三方增强命令行解释器都支持的最典型的两种命令行界面定义方式,然后再来研究一下 cmdr 新增的最平滑的迁移方案。

典型的方式

通过结构数据体定义

有的增强工具(例如 cobra,viper)采用结构体数据定义方式来完成界面指定,如同 cmdr 的这样:

rootCmd = &cmdr.RootCommand{
    Command: cmdr.Command{
        BaseOpt: cmdr.BaseOpt{
            Name:            appName,
            Description:     desc,
            LongDescription: longDesc,
            Examples:        examples,
        },
        Flags: []*cmdr.Flag{},
        SubCommands: []*cmdr.Command{
            // generatorCommands,
            // serverCommands,
            msCommands,
            testCommands,
            {
                BaseOpt: cmdr.BaseOpt{
                    Short:       "xy",
                    Full:        "xy-print",
                    Description: `test terminal control sequences`,
                    Action: func(cmd *cmdr.Command, args []string) (err error) {
                        fmt.Println("\x1b[2J") // clear screen

                        for i, s := range args {
                            fmt.Printf("\x1b[s\x1b[%d;%dH%s\x1b[u", 15+i, 30, s)
                        }

                        return
                    },
                },
            },
            {
                BaseOpt: cmdr.BaseOpt{
                    Short:       "mx",
                    Full:        "mx-test",
                    Description: `test new features`,
                    Action: func(cmd *cmdr.Command, args []string) (err error) {
                        fmt.Printf("*** Got pp: %s\n", cmdr.GetString("app.mx-test.password"))
                        fmt.Printf("*** Got msg: %s\n", cmdr.GetString("app.mx-test.message"))
                        return
                    },
                },
                Flags: []*cmdr.Flag{
                    {
                        BaseOpt: cmdr.BaseOpt{
                            Short:       "pp",
                            Full:        "password",
                            Description: "the password requesting.",
                        },
                        DefaultValue: "",
                        ExternalTool: cmdr.ExternalToolPasswordInput,
                    },
                    {
                        BaseOpt: cmdr.BaseOpt{
                            Short:       "m",
                            Full:        "message",
                            Description: "the message requesting.",
                        },
                        DefaultValue: "",
                        ExternalTool: cmdr.ExternalToolEditor,
                    },
                },
            },
        },
    },

    AppName:    appName,
    Version:    cmdr.Version,
    VersionInt: cmdr.VersionInt,
    Copyright:  copyright,
    Author:     "xxx <xxx@gmail.com>",
}
//... More

它的问题在于,如果你有 docker 那样的较多的子命令以及选项需要安排的话,这个方案会相当难定位,写起来也很痛苦,改起来更痛苦。

通过流式调用链方式定义

比结构体数据定义方案更好一点的是采用流式调用链方式。它可能长得像这样:

    // root

    root := cmdr.Root(appName, "1.0.1").
        Header("fluent - test for cmdr - no version - hedzr").
        Description(desc, longDesc).
        Examples(examples)
    rootCmd = root.RootCommand()

    // soundex

    root.NewSubCommand().
        Titles("snd", "soundex", "sndx", "sound").
        Description("", "soundex test").
        Group("Test").
        Action(func(cmd *cmdr.Command, args []string) (err error) {
            for ix, s := range args {
                fmt.Printf("%5d. %s => %s\n", ix, s, cmdr.Soundex(s))
            }
            return
        })

    // xy-print

    root.NewSubCommand().
        Titles("xy", "xy-print").
        Description("test terminal control sequences", "test terminal control sequences,\nverbose long descriptions here.").
        Group("Test").
        Action(func(cmd *cmdr.Command, args []string) (err error) {
            fmt.Println("\x1b[2J") // clear screen

            for i, s := range args {
                fmt.Printf("\x1b[s\x1b[%d;%dH%s\x1b[u", 15+i, 30, s)
            }

            return
        })

    // mx-test

    mx := root.NewSubCommand().
        Titles("mx", "mx-test").
        Description("test new features", "test new features,\nverbose long descriptions here.").
        Group("Test").
        Action(func(cmd *cmdr.Command, args []string) (err error) {
            fmt.Printf("*** Got pp: %s\n", cmdr.GetString("app.mx-test.password"))
            fmt.Printf("*** Got msg: %s\n", cmdr.GetString("app.mx-test.message"))
            fmt.Printf("*** Got fruit: %v\n", cmdr.GetString("app.mx-test.fruit"))
            fmt.Printf("*** Got head: %v\n", cmdr.GetInt("app.mx-test.head"))
            return
        })
    mx.NewFlag(cmdr.OptFlagTypeString).
        Titles("pp", "password").
        Description("the password requesting.", "").
        Group("").
        DefaultValue("", "PASSWORD").
        ExternalTool(cmdr.ExternalToolPasswordInput)
    mx.NewFlag(cmdr.OptFlagTypeString).
        Titles("m", "message", "msg").
        Description("the message requesting.", "").
        Group("").
        DefaultValue("", "MESG").
        ExternalTool(cmdr.ExternalToolEditor)
    mx.NewFlag(cmdr.OptFlagTypeString).
        Titles("fr", "fruit").
        Description("the message.", "").
        Group("").
        DefaultValue("", "FRUIT").
        ValidArgs("apple", "banana", "orange")
    mx.NewFlag(cmdr.OptFlagTypeInt).
        Titles("hd", "head").
        Description("the head lines.", "").
        Group("").
        DefaultValue(1, "LINES").
        HeadLike(true, 1, 3000)

    // kv

    kvCmd := root.NewSubCommand().
        Titles("kv", "kvstore").
        Description("consul kv store operations...", ``)
//...More

这种方式很有效地改进的痛苦之源。要说起来,也没有什么缺点了。所以这也是 cmdr 主要推荐你采用的方案。

通过结构 Tag 方式定义

这种方式被有一些第三方解释器所采用,可以算是比较有价值的定义方式。其特点在于直观、易于管理。

它的典型案例可能是这样子的:

type argT struct {
    cli.Helper
    Port int  `cli:"p,port" usage:"short and long format flags both are supported"`
    X    bool `cli:"x" usage:"boolean type"`
    Y    bool `cli:"y" usage:"boolean type, too"`
}

func main() {
    os.Exit(cli.Run(new(argT), func(ctx *cli.Context) error {
        argv := ctx.Argv().(*argT)
        ctx.String("port=%d, x=%v, y=%v\n", argv.Port, argv.X, argv.Y)
        return nil
    }))
}

不过,由于 cmdr 没有打算支持这种方案,所以这里仅介绍到这个程度。

说明一下,cmdr 之所以不打算支持这种方案,是因为这样做好处固然明显,坏处也同样令人烦恼:复杂的定义可能会因为被嵌套在 Tag 内而导致难以编写,例如多行字符串在这里就很难过。

cmdr 新增的兼容 flag 的定义方式

那么,我们回顾了两种或者三种典型的命令行界面定义方式之后,可以发现他们和 flag 之前的区别是比较大的,当你一开始设计你的 app 时,如果为了便宜和最快开始而采用了 flag 方案的话(毕竟,这是golang自带的包嘛),再要想切换到一个增强版本的话,无论哪一个都会令你痛一下。

flag 方式

我们看看当你采用 flag 方式时,你的 main 入口可能是这样的:

// old codes

package main

import "flag"

var (
      serv           = flag.String("service", "hello_service", "service name")
      host           = flag.String("host", "localhost", "listening host")
      port           = flag.Int("port", 50001, "listening port")
      reg            = flag.String("reg", "localhost:32379", "register etcd address")
      count          = flag.Int("count", 3, "instance's count")
      connectTimeout = flag.Duration("connect-timeout", 5*time.Second, "connect timeout")
)

func main(){
      flag.Parse()
      // ...
}

迁移到 cmdr

为了迁移为使用 cmdr,你可以简单地替换 import "flag" 语句为这样:

import (
  // “flag”
  "github.com/hedzr/cmdr/flag"
)

其它内容一律不变,也就是说完整的入口现在像这样:

// new codes

package main

import (
  // “flag”
  "github.com/hedzr/cmdr/flag"
)

var (
      serv           = flag.String("service", "hello_service", "service name")
      host           = flag.String("host", "localhost", "listening host")
      port           = flag.Int("port", 50001, "listening port")
      reg            = flag.String("reg", "localhost:32379", "register etcd address")
      count          = flag.Int("count", 3, "instance's count")
      connectTimeout = flag.Duration("connect-timeout", 5*time.Second, "connect timeout")
)
  
func main(){
    flag.Parse()
    // ...
}

怎么样,足够简单吧?

引入增强特性

那么我们现在期望引入更多 cmdr 专有特性怎么办呢?

例如想要全名(完整单词)作为长选项,补充短选项定义,这可以通过如下的序列来达成:

import (
    // “flag”
      "github.com/hedzr/cmdr"
      "github.com/hedzr/cmdr/flag"
)

var(
    // uncomment this line if you like long opt (such as --service)
    treatAsLongOpt = flag.TreatAsLongOpt(true)
  
    serv = flag.String("service", "hello_service", "service name",
                       flag.WithShort("s"),
                       flag.WithDescription("single line desc", `long desc`))
)

类似的可以完成其他增强特性的定义。

可用的增强特性

所有 cmdr 特性被浓缩在几个少量的接口中了。此外,某些特性是当你使用 cmdr 时就立即获得了,无需其它表述或设定(例如短选项的组合,自动的帮助屏,多级命令等等)。

所有的这些需要指定适当参数的特性,包含在如下的这些定义中:

flag.WithTitles(short, long string, aliases ...string) (opt Option)

定义短选项,长选项,别名。

综合来说,你必须在某个地方定义了一个选项的长名字,因为这是内容索引的依据,如果长名字缺失,那么可能会有意料之外的错误。

别名是随意的。

如果可以,尽可能提供短选项。

短选项一般来说是一个字母,然而使用两个甚至更多字母是被允许的,这是为了提供多种风格的命令行界面的兼容性。例如 wget, rar 都采用了双字母的短选项。而 golang flag 自身支持的是任意长度的短选项,没有长选项支持。cmdr 在短选项上的宽松和兼容程度,是其它几乎所有第三方命令行参数解释器所不能达到的。

flag.WithShort(short string) (opt Option)

提供短选项定义。

flag.WithLong(long string) (opt Option)

提供长选项定义。

flag.WithAliases(aliases ...string) (opt Option)

提供别名定义。别名是任意多、可选的。

flag.WithDescription(oneLine, long string) (opt Option)

提供描述行文本。

oneLine 提供单行描述文本,这通常是在参数被列表时。long 提供的多行描述文本是可选的,你可以提供空字串给它,这个文本在参数被单独显示帮助详情时会给予用户更多的描述信息。

flag.WithExamples(examples string) (opt Option)

可以提供参数用法的命令行实例样本。

这个字串可以是多行的,它遵照一定的格式要求,我们以前的文章中对该格式有过描述。这样的格式要求是为了在 man/info page 中能够获得更视觉敏锐的条目,所以你可以自行判定要不要遵守规则。

flag.WithGroup(group string) (opt Option)

命令或者参数都是可以被分组的。

分组是可以被排序的。给 group 字串一个带句点的前缀,则这个前缀会被切割出来用于排序,排序规则是 A-Z0-9a-z 按照 ASCII 顺序。所以:

  • 1001.c++, 1100.golang, 1200.java, …;
  • abcd.c++, b999.golang, zzzz.java, …;

是有顺序的。

由于第一个句点之前的排序用子串被切掉了,因此你的 group 名字可以不受这个序号的影响。

给分组一个空字串,意味着使用内置的 分组,这个分组被排列在其他所有分组之前。

给分组一个 cmdr.UnsortedGroup 常量,则它会被归纳到最后一个分组中。值得注意的是,最后一个分组,依赖的是 cmdr.UnsortedGroup 常量的具体值zzzz.unsorted,所以,你仍然有机会定义一个别的序号来绕过这个“最后”。

flag.WithHidden(hidden bool) (opt Option)

hidden为true是,该选项不会被列举到帮助屏中。

flag.WithDeprecated(deprecation string) (opt Option)

一般来说,你需要给 deprecation 提供一个版本号。这意味着,你提醒最终用户该选项从某个版本号开始就已经被废弃了。

按照 Deprecated 的礼貌规则,我们废弃一个选项时,首先标记它,并给出替代提示,然后在若干次版本迭代之后正式取消它。

flag.WithAction(action func(cmd *Command, args []string) (err error)) (opt Option)

按照 cmdr 的逻辑,一个选项在被显式命中时,你可以提供一个即时的响应动作,这可能允许你完成一些特别的操作,例如为相关联的其它一组选项调整默认值什么的。

flag.WithToggleGroup(group string) (opt Option)

如果你打算定义一组选项,带有互斥效果,如同 radio button group 那样,那么你可以为它们提供相同的 WithToggleGroup group name这个名字和 WithGroup group name 没有任何关联关系

flag.WithDefaultValue(val interface{}, placeholder string) (opt Option)

提供选项的默认值以及占位符。

默认值的数据类型相当重要,因为这个数据类型时后续抽取该选项真实值的参考依据。

例如,int数据一定要提供一个 int 数值,Duration数据一定要提供一个 3*time.Second 这样的确切数值。

flag.WithExternalTool(envKeyName string) (opt Option)

提供一个环境变量名,例如 EDITOR。那么,如果该选项的 value 部分没有在命令行中被提供时,cmdr 会搜索环境变量的值,将其作为控制台终端应用程序运行,并收集该运行的结果(一个临时文件的文件内容)用于为该选项复制。

如同 git commit -m 那样。

flag.WithValidArgs(list ...string) (opt Option)

提供一个枚举表,用于约束用户所提供的值。

flag.WithHeadLike(enable bool, min, max int64) (opt Option)

当该选项被设定为 enable=true 时,识别用户输入的诸如 -1973, -211 之类的整数短选项,将其整数数值作为本选项的数值。

如同 head -9 等效于 head -n 9 那样。

结束语

好了。很多内容。不过还是堆出来了,自己欣慰一下。

真正的结束语

嗯,cmdrv1.0.3 是一个 pre-release 版本,我们已经提供一个 flag 的最平滑迁移的基本实现。

最近的日子里,我们会考虑完成子命令部分,并最终释出 v1.1.0,请期待。

如果认为这样做有价值的话,考虑去鼓励一下。


hedzr
95 声望19 粉丝